Skip to content

fix(studio): a stale project id in the URL no longer blocks editing for the tab - #4188

Merged
vanceingalls merged 1 commit into
mainfrom
studio-hash-project-validate
Sep 19, 2026
Merged

vanceingalls merged 1 commit into
mainfrom
studio-hash-project-validate

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

The bug

The project id in the location hash is user-supplied and outlives the project it names. Rename the folder, or open a bookmark from a project that's gone, and it points at nothing.

useServerConnection trusted it unconditionally:

if (hashProjectId) {
  setProjectId(hashProjectId);   // never checked
  setWaitingForServer(false);
}

So every later /api/projects/<id>/... request 404s for the life of the tab — including the composition read that opens the SDK session. Studio falls back to the server path for every edit, and the resolver shadow never runs either, so nothing records why.

How it was found

The stage: read reason split shipped in v0.8.51. Within 24 hours it named the cause:

version stage reason status events users
0.8.51 read http_error 404 120 5
0.8.51 read absent_or_empty 10 4

~24 events per user, none recovering — the shape of a bad hash that never gets rewritten. A missing file answers 200 + "" and shows as absent_or_empty, so these are genuinely unresolved projects, not missing compositions.

On this route a 404 has exactly one origin — adapter.resolveProject returned null. The other failure modes answer 403, and the GET doesn't set mustExist.

The fix

Validate through GET /api/projects/:id, which calls the same adapter.resolveProject the file routes use. If it's gone, fall back to the first project and rewrite the hash — the path a hashless load already takes.

Why not match against the /api/projects list (which the same code already fetches): that would be wrong twice over.

  • It omits session ids, which resolveProject resolves fine via sessions/<id>.json.
  • It skips project dirs without an index.html/<name>.html.

Either would turn a working deep link into a silent redirect.

The check is tri-state on purpose. Only a definite 404 counts as missing; a rejected request or a 5xx returns unknown and keeps the hash. A network blip must not rewrite a valid deep link out from under the user.

Before

Loading #project/deleted-project — a hash id the server cannot resolve (GET /api/projects/deleted-project → 404). The hash is left untouched, the file tree never loads, and the composition read 404s for the life of the tab: "No compositions found", 00:00/00:00.

before

After

Same URL, same server. The unresolvable id is detected, Studio falls back to the real project and rewrites the hash to #project/demo-capture — tree loaded, index.html open, 00:00/00:03.

after

Verification

  • 4 new tests: resolvable hash kept, gone hash falls back + hash rewritten, rejected check keeps hash, 5xx keeps hash.
  • Mutation-tested both directions. Disabling the fallback fails the gone-hash test; collapsing unknown into missing fails both keep-the-hash tests.
  • packages/studio: 99 files, 843 tests pass. tsc --noEmit clean, oxlint 0/0, oxfmt clean.

Note on the remaining absent_or_empty

The other 10 events are not covered here and I'm not guessing at them. The obvious cause — a stale comp path in the same URL — is already guarded: useHydrateActiveCompPathFromUrl runs normalizeStudioCompositionPath(urlPath, fileTree) against the loaded tree. So that class needs its own root-cause rather than a speculative fix.

🤖 Generated with Claude Code

@mintlify

mintlify Bot commented Sep 19, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Sep 19, 2026, 8:45 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

…or the tab

The project id in the location hash is user-supplied and outlives the project
it names: rename the folder, or open a bookmark from a project that is gone,
and it points at nothing. useServerConnection trusted it unconditionally, so
every later /api/projects/<id>/... request 404'd for the life of the tab —
including the composition read that opens the SDK session. Studio then fell
back to the server path for every edit, and the resolver shadow never ran
either, so nothing recorded why.

Telemetry, 24h after the read-reason split shipped in v0.8.51: 120
`stage: read` / `http_error` 404 reads across 5 users, about 24 each, none
recovering. On this route a 404 has exactly one origin — resolveProject
returned null — since the other failure modes answer 403 and the GET does not
set mustExist.

Validates through /api/projects/:id, which calls the same
adapter.resolveProject the file routes use. Matching against the
/api/projects list instead would be wrong twice over: that list omits session
ids, which resolve fine, and skips project dirs without an index.html.

The check is tri-state on purpose. Only a definite 404 counts as missing; a
rejected request or a 5xx keeps the hash, so one network blip cannot rewrite
a valid deep link out from under the user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — the bug is real, the fix targets the right equivalence, and the tri-state is the correct shape. One gap worth closing and one smaller observation, both non-blocking, written out so you can decide rather than take my word for the grading.

Verified at source

  • The 404 contract the tri-state rests on holds. GET /projects/:id (packages/studio-server/src/routes/projects.ts:51-53) has exactly one 404 in it: adapter.resolveProject(...) returning null. No mustExist, no other status in the handler. So "404 ⟺ the server cannot resolve this id" is true for this route, which is what makes acting on missing safe.
  • "Matching the /api/projects list would be wrong twice over" — both halves check out, and this is the most interesting decision in the PR:
    • Session ids. listProjects keys entries on the project directory name (packages/studio/vite.adapter.ts:197, id: d.name) and carries the session only as a separate sessionId field, so a session id is never in the list's id set. resolveProject explicitly falls through to sessions/<id>.json and resolves it (:211-227). Matching against the list would have redirected a working session deep link.
    • Dirs without an index. listProjects filters on index.html || <name>.html (:191-192); resolveProject has no such filter and returns the dir if it exists (:235). Resolvable, unlisted.
  • Encode/decode symmetry. parseProjectIdFromHash decodes (projectRouting.ts:64) and resolveHashProject re-encodes with encodeURIComponent, so an id containing a space or a % is not double-encoded on the way to the probe.
  • The mutation claims are true by inspection, and the tests are not vacuous. Removing the fallback kills "falls back when the hash id is gone"; collapsing unknown into missing kills both keep-the-hash tests. The fallback test's list project (real-project) is deliberately different from the hash id, so it proves the hash id lost rather than merely that something was set.

The gap: nothing re-validates after mount

resolveHashProject runs inside useMountEffect. The hook's own hashchange listener is untouched and still adopts any later id with no check:

// useServerConnection.ts:107-114 — unchanged by this PR
const next = parseProjectIdFromHash(window.location.hash);
if (next && next !== projectId) setProjectId(next);

The reachable sequence is one click, and this PR is what creates it:

  1. Load #project/deleted-project → the probe 404s → fall back and window.location.hash = buildProjectHash("demo-capture") (:86). Assigning to location.hash pushes a history entry.
  2. The user presses Back — plausible, because from their side the app just silently moved them off the project they asked for.
  3. The hash returns to #project/deleted-project, hashchange fires, setProjectId("deleted-project") runs unvalidated. The mount effect has already run, so nothing rewrites it again: the tab is back in the exact 404-for-its-lifetime state, with the same absent telemetry.

So the title's "for the tab" holds for the load path, and stops holding one Back press later. Two ways out, not equivalent:

  • history.replaceState(null, "", buildProjectHash(first.id)) instead of assigning location.hash. The dead id never enters history, so Back leaves the page rather than restoring it. Cheapest, and it does not depend on the hashchange firing since projectId is already set at :84.
  • Route onHashChange through resolveHashProject. Covers the general case too — pasting a stale hash into a live tab — at the cost of an async listener.

I would take the first unless in-tab hash edits are a real path for you, in which case the second is the actual fix.

Why I am not blocking on it: that listener behaved identically before this PR, so nothing regresses, and the load path you targeted is genuinely fixed. What would flip it is treating the silent redirect as the user-visible recovery — Back is the normal response to being redirected.

Smaller: the hash path can now reach the retry loop

missing plus an empty project list falls through to scheduleRetry() (:88), which sets waitingForServer and polls every 2 s. A hash-carrying load could not reach that before, because the hash short-circuited above it. On a healthy server that simply has no projects, the user now sits in the waiting-for-server state indefinitely, which names the wrong cause. Narrow, and still better than the old silent breakage — worth a line only because it is a new state for this path rather than a change in degree.

Scope

I read both changed files whole, plus the three untouched pieces the fix depends on: the route's 404 origin, both adapter project resolvers, and the hash codec. I did not run the suite — the 843-pass number is from your body, and the mutation claims above I checked by reading, not by executing.

— Rames

@vanceingalls
vanceingalls merged commit 3685b76 into main Sep 19, 2026
50 checks passed
@vanceingalls
vanceingalls deleted the studio-hash-project-validate branch September 19, 2026 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants